﻿using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Tasks;
using ICSharpCode.CodeConverter;
using ICSharpCode.CodeConverter.CSharp;
using ICSharpCode.CodeConverter.Shared;
using ICSharpCode.CodeConverter.Util;
using Microsoft.Build.Locator;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.MSBuild;
using Microsoft.VisualBasic.FileIO;
using Microsoft.VisualStudio.Threading;
using Xunit;
using SearchOption = System.IO.SearchOption;

namespace CodeConverter.Tests.TestRunners
{
    /// <summary>
    /// For all files in the testdata folder relevant to the testname, ensures they match the result of the conversion.
    /// Any extra files generated by the conversion are ignored.
    ///
    /// To add a new multi-file characterization test:
    /// 1. Open TestData\MultiFileCharacterization\SourceFiles\CharacterizationTestSolution.sln in another Visual Studio instance and make any changes to the source files.
    /// 2. Set _writeNewCharacterization to true
    /// 3. Run the MultiFileSolutionAndProjectTests for both VB and CSharp.
    /// 4. Set _writeNewCharacterization to false
    /// 5. Commit the result
    /// If you're testing something specific, try to make it clear with a well-named method/class/file or by adding comments in the source file.
    /// </summary>
    /// <remarks>
    /// Using [Collection(MultiFileTestFixture.Collection)] will allow this singleton to be injected via the test class constructor.
    /// https://xunit.net/docs/shared-context
    /// </remarks>
    [CollectionDefinition(Collection)]
    public sealed class MultiFileTestFixture : ICollectionFixture<MultiFileTestFixture>, IDisposable
    {
        public const string Collection = "Uses MSBuild";
        /// <summary>
        /// Turn it and run the test, then you can manually check the output loads/builds in VS.
        /// </summary>
        private readonly bool _writeAllFilesForManualTesting = false;

        private readonly Lazy<MSBuildWorkspace> _msBuildWorkspace;
        private readonly AsyncLazy<Solution> _solution;
        private static readonly string MultiFileCharacterizationDir = Path.Combine(TestConstants.GetTestDataDirectory(), "MultiFileCharacterization");
        private static readonly string OriginalSolutionDir = Path.Combine(MultiFileCharacterizationDir, "SourceFiles");
        private static readonly string SolutionFile = Path.Combine(OriginalSolutionDir, "CharacterizationTestSolution.sln");

        public MultiFileTestFixture()
        {
            _msBuildWorkspace = new Lazy<MSBuildWorkspace>(CreateWorkspace);
            _solution = new AsyncLazy<Solution>(() => GetSolutionAsync(SolutionFile));
        }

        private async Task<Solution> GetSolutionAsync(string solutionFile)
        {
            await RestorePackagesForSolution(solutionFile);
            var solution = await _msBuildWorkspace.Value.OpenSolutionAsync(solutionFile);
            await AssertMSBuildIsWorkingAndProjectsValid(_msBuildWorkspace.Value.Diagnostics, solution.Projects);
            return solution;
        }

        private static async Task RestorePackagesForSolution(string solutionFile)
        {
            var psi = new ProcessStartInfo("dotnet", $"restore \"{solutionFile}\""){UseShellExecute = false, RedirectStandardError =  true, RedirectStandardOutput = true};
            Process dotnetRestore = Process.Start(psi);
            Assert.NotNull(dotnetRestore);
            await dotnetRestore.WaitForExitAsync();
            if (dotnetRestore.ExitCode != 0) Assert.True(false, dotnetRestore.StandardOutput.ReadToEnd() + " " + dotnetRestore.StandardError.ReadToEnd());
        }

        public void Dispose()
        {
            if (_msBuildWorkspace.IsValueCreated) _msBuildWorkspace.Value.Dispose();
        }

        public async Task ConvertProjectsWhere<TLanguageConversion>(Func<Project, bool> shouldConvertProject, [CallerMemberName] string expectedResultsDirectory = "") where TLanguageConversion : ILanguageConversion, new()
        {
            bool recharacterizeByWritingExpectedOverActual = TestConstants.RecharacterizeByWritingExpectedOverActual;

            var languageNameToConvert = typeof(TLanguageConversion) == typeof(VBToCSConversion)
                ? LanguageNames.VisualBasic
                : LanguageNames.CSharp;

            var projectsToConvert = (await _solution.GetValueAsync()).Projects.Where(p => p.Language == languageNameToConvert && shouldConvertProject(p)).ToArray();
            var conversionResults = await SolutionConverter.CreateFor<TLanguageConversion>(projectsToConvert).Convert().ToDictionaryAsync(c => c.TargetPathOrNull, StringComparer.OrdinalIgnoreCase);
            var expectedResultDirectory = GetExpectedResultDirectory<TLanguageConversion>(expectedResultsDirectory);

            try {
                if (!expectedResultDirectory.Exists) expectedResultDirectory.Create();
                var expectedFiles = expectedResultDirectory.GetFiles("*", SearchOption.AllDirectories)
                    .Where(f => !f.FullName.Contains(@"\obj\") && !f.FullName.Contains(@"\bin\")).ToArray();
                AssertAllExpectedFilesAreEqual(expectedFiles, conversionResults, expectedResultDirectory, OriginalSolutionDir);
                AssertAllConvertedFilesWereExpected(expectedFiles, conversionResults, expectedResultDirectory, OriginalSolutionDir);
                AssertNoConversionErrors(conversionResults);
            } finally {
                if (recharacterizeByWritingExpectedOverActual) {
                    if (expectedResultDirectory.Exists) expectedResultDirectory.Delete(true);
                    if (_writeAllFilesForManualTesting) FileSystem.CopyDirectory(OriginalSolutionDir, expectedResultDirectory.FullName);

                    foreach (var conversionResult in conversionResults) {
                        var expectedFilePath =
                            conversionResult.Key.Replace(OriginalSolutionDir, expectedResultDirectory.FullName);
                        Directory.CreateDirectory(Path.GetDirectoryName(expectedFilePath));
                        File.WriteAllText(expectedFilePath, conversionResult.Value.ConvertedCode);
                    }
                }
            }

            Assert.False(recharacterizeByWritingExpectedOverActual, $"Test setup issue: Set {nameof(recharacterizeByWritingExpectedOverActual)} to false after using it");
        }

        private static MSBuildWorkspace CreateWorkspace()
        {
            try {
                return CreateWorkspaceUnhandled();
            } catch (NullReferenceException e) {
                Assert.True(false, "MSBuild nullrefs sometimes, just run the test again." + e);
                return null;
            }
        }

        private static MSBuildWorkspace CreateWorkspaceUnhandled()
        {
            var instances = MSBuildLocator.QueryVisualStudioInstances();
            MSBuildLocator.RegisterInstance(instances.OrderByDescending(x => x.Version).First(x => x.Version.Major >= 16));
            return MSBuildWorkspace.Create(new Dictionary<string, string>()
            {
                {"Configuration", "Debug"},
                {"Platform", "AnyCPU"}
            });
        }

        /// <summary>
        /// If you've changed the source project not to compile, the results will be very confusing
        /// If this happens randomly, updating the Microsoft.Build dependency may help - it may have to line up with a version installed on the machine in some way.
        /// </summary>
        private static async Task AssertMSBuildIsWorkingAndProjectsValid(
            ImmutableList<WorkspaceDiagnostic> valueDiagnostics, IEnumerable<Project> projectsToConvert)
        {
            var errors = await projectsToConvert.ParallelSelectAwait(async x => {
                var c = await x.GetCompilationAsync();
                return new[]{CompilationWarnings.WarningsForCompilation(c, c.AssemblyName)}.Concat(
                    valueDiagnostics.Where(d => d.Kind > WorkspaceDiagnosticKind.Warning).Select(d => d.Message));
            }, Env.MaxDop, default).ToArrayAsync();
            var errorString = string.Join("\r\n", errors.SelectMany(w => w).Where(w => w != null));
            Assert.True(errorString == "", errorString);
        }

        private static void AssertAllConvertedFilesWereExpected(FileInfo[] expectedFiles,
            Dictionary<string, ConversionResult> conversionResults, DirectoryInfo expectedResultDirectory,
            string originalSolutionDir)
        {
            AssertSubset(expectedFiles.Select(f => f.FullName.Replace(expectedResultDirectory.FullName, "")), conversionResults.Select(r => r.Key.Replace(originalSolutionDir, "")),
                "Extra unexpected files were converted");
        }

        private void AssertAllExpectedFilesAreEqual(FileInfo[] expectedFiles, Dictionary<string, ConversionResult> conversionResults,
            DirectoryInfo expectedResultDirectory, string originalSolutionDir)
        {
            foreach (var expectedFile in expectedFiles) {
                AssertFileEqual(conversionResults, expectedResultDirectory, expectedFile, originalSolutionDir);
            }
        }

        private static void AssertNoConversionErrors(Dictionary<string, ConversionResult> conversionResults)
        {
            var errors = conversionResults
                .SelectMany(r => (r.Value.Exceptions ?? Array.Empty<string>()).Select(e => new { Path = r.Key, Exception = e }))
                .ToList();
            Assert.Empty(errors);
        }

        private static void AssertSubset(IEnumerable<string> superset, IEnumerable<string> subset, string userMessage)
        {
            var notExpected = new HashSet<string>(subset, StringComparer.OrdinalIgnoreCase);
            notExpected.ExceptWith(new HashSet<string>(superset, StringComparer.OrdinalIgnoreCase));
            Assert.False(notExpected.Any(), userMessage + "\r\n" + string.Join("\r\n", notExpected));
        }

        private void AssertFileEqual(Dictionary<string, ConversionResult> conversionResults,
            DirectoryInfo expectedResultDirectory,
            FileInfo expectedFile,
            string actualSolutionDir)
        {
            var convertedFilePath = expectedFile.FullName.Replace(expectedResultDirectory.FullName, actualSolutionDir);
            var fileDidNotNeedConversion = !conversionResults.ContainsKey(convertedFilePath) && File.Exists(convertedFilePath);
            if (fileDidNotNeedConversion) return;

            Assert.True(conversionResults.ContainsKey(convertedFilePath), expectedFile.Name + " is missing from the conversion result of [" + string.Join(",", conversionResults.Keys) + "]");

            var expectedText = File.ReadAllText(expectedFile.FullName);
            var conversionResult = conversionResults[convertedFilePath];
            var actualText = conversionResult.ConvertedCode ?? "" + conversionResult.GetExceptionsAsString() ?? "";

            OurAssert.EqualIgnoringNewlines(expectedText, actualText);
            Assert.Equal(GetEncoding(expectedFile.FullName), GetEncoding(conversionResult));
        }

        private Encoding GetEncoding(ConversionResult conversionResult)
        {
            var filePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
            conversionResult.TargetPathOrNull = filePath;
            conversionResult.WriteToFile();
            var encoding = GetEncoding(filePath);
            File.Delete(filePath);
            return encoding;
        }

        private static Encoding GetEncoding(string filePath)
        {
            using (var reader = new StreamReader(filePath, true)) {
                reader.Peek();
                return reader.CurrentEncoding;
            }
        }

        private static DirectoryInfo GetExpectedResultDirectory<TLanguageConversion>(string testFolderName) where TLanguageConversion : ILanguageConversion, new()
        {
            string conversionDirectionFolderName = typeof(TLanguageConversion).Name.Replace("Conversion", "Results");
            var path = Path.Combine(MultiFileCharacterizationDir, conversionDirectionFolderName, testFolderName);
            return new DirectoryInfo(path);
        }
    }
}